3.4 内联函数¶
简介¶
Tips:内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,而且一个75行的函数也不大可能在调用点内联地展开。
使用内联函数可以避免函数调用的开销,它会在每个调用点上“内联地”展开。
inline const string& shorterString(const string &s1, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}
定义在头文件中¶
和其他函数不同,内联函数可以在程序中多次定义。毕竟编译器想要展开函数仅有函数声明时不够的,还需要函数的定义。由于对于某个给定的内联函数来说,它的多个定义必须完全一致,基于这个原因内联函数通常定义在头文件中。
定义在类内部的函数是隐式的内联函数¶
Tips:和我们在头文件中定义inline函数的原因一样,inline成员函数也应该与相应的类定义在同一个头文件中。
在类中常有一些规模较小的函数适合于被声明成内联函数,其中定义在类内部的成员函数和友元函数是自动inline的,在类的外部我们可以用inline关键字修饰函数定义将其显式声明为内联函数。
声明为内联的函数也不一定会被编译器内联¶
有些函数即使被声明为内联的也不一定会被编译器内联,比如虚函数和递归函数就不会被正常内联:
递归层数在编译时可能是未知的,因此大多数编译器都不支持内联递归函数
用类指针调用虚函数时不会被内联展开,因为此时编译器还不知道运行时哪个函数会被调用
编码规范:彻底了解inlining的里里外外¶
Effective C++:Understand the ins and outs inlining.
将大多数inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级(binary ungradability)更容易,也可以使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
不要只因为function templates出现在头文件,就将它们声明为inline。
1. inline函数的优缺点¶
inline函数可以使你调用它们且不需要蒙受函数调用所导致的额外开销。另外编译器最优化机制通常被设计用来浓缩那些“不含函数调用”的代码,所以当你inline某个函数,或许编译器就因此有能力对它(函数本体)执行语境相关最优化。大部分编译器绝不会对着一个“outlined函数调用”动作执行这样的优化。
inline函数背后的逻辑是将“对此函数的每一个调用”都以函数本体替换之,这样做可能增加你的目标码(object code)大小。在一台内存有限的机器上,过度热衷inlining会造成程序体积太大(对可用空间而言)。即使拥有虚内存,inline造成的代码膨胀也会导致额外的换页行为(paging),降低指令高速缓存装置的击中率(instruction cache hit rate),以及这些伴随而来的效率损失。
换个角度说,如果inline函数本体很小,编译器针对“函数本体”产出的码可能比针对“函数调用”所产出的码更小。这样将函数inlining确实可能导致较小的目标码(object code)和较高的指令高速缓存装置击中率。
另外inline函数无法随着程序库的升级而升级,假设f()是程序库内的一个inline函数,客户端将其函数本体编进其程序中。一旦程序库设计者决定修改f()的实现,所有用到该函数的客户端程序都必须重新编译。但是如果f()是non-inline函数,一旦它有任何修改,客户端只需重新链接即可,远比重新编译的成本少得多。如果程序库采取动态链接,升级版函数甚至可以不知不觉地被应用程序吸纳。
2. inline常处于头文件中¶
inline函数通常一定被置于头文件内,因为大多数C++程序在编译期间进行inlining,而为了将一个“函数调用”替换成“被调用函数的本体”,编译器必须知道那个函数长什么样子。
3. template与inline¶
template通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。
template的具现化与inlining无关。如果你正在写一个template且希望此template具现出来的函数都应该inlined,请将此template声明为inline。但如果你写的template没理由要求它所具现化的每一个函数都是inlined,就应该避免将这个template声明为inline(无论显式还是隐式),否则可能会导致代码膨胀。
4.virtual函数与inline¶
一个表面上看似inline的函数未必真的是inline函数,这取决于编译器。
大部分编译器拒绝将太过复杂(例如带有循环或递归)的函数inlining,而所有对virtual函数的调用(除非是最平淡无奇的)也都会使inlining落空。因为virtual意味着直到运行期才直到调用哪个函数,而inline意味着执行前先将调用动作替换为被调用函数的本体。如果编译器不知道该调用哪个函数,你就很难责备它们拒绝它们将函数本体inlining。
5. inline函数也可能生成outlined函数本体¶
有时候虽然编译器有意愿inlining某个函数,还是可能为该函数生成一个函数本体。
举个例子,如果程序要取得某个inline函数的地址,编译器通常必须为此函数生成一个outlined函数本体。毕竟编译器没法生成一个指针指向并不存在的函数。编译器通常不对“通过函数指针而进行的调用”实施inlining,这意味对inline函数的调用可能被inlined,也可能不被inlined,取决于调用的实施方式:
inline void f() {...} // 假设编译器有意愿inline对f的调用
viud (*pf)() = f; // pf指向f
f(); // 这个调用将被inlined, 因为它是一个正常调用
pf(); // 这个调用或许不被inlined, 因为它通过函数指针达成
有时候编译器会生成构造函数和析构函数的outline副本,如此一来它们就可以获得指针指向那些函数,在array内部元素的构造和析构过程中使用。
实际上构造函数和析构函数往往是inlining的糟糕候选人,以下面的Derived类为例:
class Base {
public:
...
private:
std::string bm1, bm2;
};
class Derived : public Base {
public:
Derived() {} // Derived构造函数看似是空的, 然而事实如此吗?
...
private:
std::string dm1, dm2, dm3;
};
C++对于“对象被创建和销毁时发生什么事”做了各式各样的保证,例如如果有个异常在对象构造期间被抛出,该对象已构造好的那一部分会被自动销毁 。编译器为表面上为空的Derived构造函数产生的代码相当于:
// 空白Derived构造函数的观念性实现
Derived::Derived() {
// 初始化Base成分
Base::Base();
// 试图构造dm1, 如果抛出异常销毁Base成员并抛出异常
try { dm1.std::string::string(); }
catch (...) {
Base::~Base();
throw;
}
// 试图构造dm2, 如果抛出异常就销毁dm1和base部分, 并抛出异常
try { dm2.std::string::string(); }
catch (...) {
dm1.std::string::~string();
Base::~Base();
throw;
}
// 试图构造dm3, 如果抛出异常就销毁dm2、dm1和base部分, 并抛出异常
try { dm3.std::string::string(); }
catch (...) {
dm2.std::string::~string();
dm1.std::string::~string();
Base::~Base();
throw;
}
}
上面这段代码并不能代表编译器真正制造出来的代码,因为真正的编译器会以更加复杂的做法来处理异常。无论编译器在其内部所做的异常处理多么精致复杂,Derived构造函数至少一定会陆续调用其成员变量和base class两者的构造函数,而那些调用会影响编译器是否对此空白函数inlining。